파이썬 클린코드 | 5장 데코레이터를 사용한 코드 개선

파이썬 클린코드 | 5장 데코레이터를 사용한 코드 개선

날짜
Nov 25, 2023
태그
python
설명
코드 재사용과 DRY 원칙을 따르는 유용한 도구
 

파이썬의 데코레이터

 
📌
학습 목표 - 파이썬에서 데코레이터가 동작하는 방식을 이해 - 함수와 클래스에 적용되는 데코레이터 구현 - 데코레이터의 효과적인 구현 방법 - 데코레이터를 활용한 코드 중복 회피 - 데코레이터를 활용한 관심사의 분리 - 좋은 데코레이터의 사례 - 일반적인 상황, 관용구, 패턴의 이해
 
def original(): ... original = modifier(original)
@modifier # 데코레이터 def original(): # 래핑된 객체 ...
def modifier(func): def wrapper(): print("before called") func() print("after called") return wrapper @modifier def hello(): print("hello!") >>> hello() before called hello! after called
데코레이터는 보통 ‘@’ 심볼을 사용해 함수나 클래스 위에 배치된다. 데코레이터 바로 아래의 위치한 함수나 클래스가 데코레이터의 첫번째 파라미터로 전달된다.
 
데코레이터는 내부적으로 받은 함수나 클래스를 가지고 특정 기능을 추가하거나 수정한 후, 수정된 함수나 클래스를 반환한다.
 

함수 데코레이터

from functools import wraps import logging # 로거 설정 logger = logging.getLogger(__name__) logging.basicConfig(level=logging.INFO) class ControlledException(Exception): """도메인에서 발생하는 일반적인 예외""" def retry(operation): @wraps(operation) def wrapped(*args, **kwargs): last_raised = None RETRIES_LIMIT = 3 for _ in range(RETRIES_LIMIT): try: return operation(*args, **kwargs) except ControlledException as e: logger.info("%s 재시도", operation.__qualname__) last_raised = e raise last_raised return wrapped
@retry def run_operator(task): """실행중 예외가 발생할 것으로 예상되는 특정 작업을 실행""" return task.run() # run_operation = retry(run_operation)과 같다
 

클래스 데코레이터

클래스 데코레이터는 코드 재사용과 DRY 원칙의 모든 이점을 공유한다.
  1. 여러 클래스가 특정 인터페이스나 기준을 따르도록 강제할 수 있다.
  1. 당장 작은 클래스를 생성하고 나중에 데코레이터로 기능을 보강할 수 있다.
  1. 유지보수 시 데코레이터를 사용해 기존 로직을 훨씬 쉽게 변경할 수 있다.
 
class LoginEventSerializer: def __init__(self, event): self.event = event def serializer(self) -> dict: return { "username": self.event.username, "password": "**민감한 정보 삭제**", "ip": self.event.ip, "timestamp": self.event.timestamp.strftime("%Y-%m-%d %H:%M"), } @dataclass class LoginEvent: SERIALIZER = LoginEventSerializer username: str password: str ip: str timestamp: datetime def serializer(self) -> dict: return self.SERIALIZER(self).serializer()
현재 동작에는 문제가 없지만, 확장할수록 문제가 발생한다.
  1. 클래스가 많아진다. - 이벤트 클래스와 직렬화 클래스가 1대 1로 매핑되어 있으므로 직렬화 클래스가 점점 많아진다.
  1. 유연하지 않다. - 만약 password를 가진 다른 클래스에서도 이 필드를 숨기려면 함수로 분리한 다음 여러 클래스에서 호출해야 한다. 이는 코드를 충분히 재사용했다고 볼 수 없다.
  1. 표준화 - serializer() 메서드는 모든 이벤트 클래스에 있어야 한다. 비록 믹스인을 사용해 분리할 수 있지만 올바른 상속이라고는 볼 수 없다.
 
from datetime import datetime def hide_field(field) -> str: return "**민감한 정보 삭제**" def format_time(field_timestamp: datetime) -> str: return field_timestamp.strftime("%Y-%m-%d %H:%M") def show_original(event_field): return event_field class EventSerializer: def __init__(self, serialization_fields: dict) -> None: self.serialization_fields = serialization_fields def serialize(self, event) -> dict: return { "field": transformation(getattr(event, field)), for field, transformation in self.serialization_fields.items() } class Serialization: def __init__(self, **transformations): self.serializer = EventSerializer(transformations) def __call__(self, event_class): def serialize_method(evnet_instance): return self.serializer.serialize(event_instance) event_class.serialize = serialize_method return event_class @Serialization( username=show_original, password=hide_field, ip=show_original, timestamp=format_time, ) @dataclass class LoginEvent: username: str, password: str, ip: str, timestamp: datetime
>>> response = ( LoginEvent( username="googie", password="1234", ip="192.168.0.26", timestamp="2023-12-07 00:43:47.068050" ) ) >>> response.serialize()
데코레이터의 사용으로 다른 클래스의 코드를 확인하지 않고도 각 필드가 어떻게 처리되는지 쉽게 알 수 있게 되었다. 데코레이터는 스택 형태로 쌓일 수 있다.
 
 

고급 데코레이터

 

데코레이터에 인자 전달

테코레이터에 파라미터를 전달받아 로직을 추상화한다면 더 강력해질 수 있다.
  1. 간접 참조: 새로운 레벨의 중첩함수를 만들어 데코레이터의 모든 것을 더 깊게 만드는 것
  1. 데코레이터를 위한 클래스를 만드는 것
 
일반적으로 두 번째가 가독성이 더 좋다. 여러 단계로 중첩된 클로저 함수보다 객체가 이해하기 쉽기 때문이다.
 

중첩 함수를 사용한 데코레이터

크게 보면 데코레이터는 함수를 파라미터로 받아서 함수를 반환하는 함수이다. 고차함수라고 부른다.
 
@retry(arg1, arg2) # 데코레이터에 파라미터를 넘긴다. # 코드 상의 의미는 다음과 같다. <original_function> = retry(arg1, arg2, ...)(<original_function>)
_DEFAULT_RETRIES_LIMIT = 3 def with_retry( retries_limit: int = _DEFAULT_RETRIES_LIMIT, allowed_exceptions: Optional[Sequence[Exception]] = None ): allowed_exceptions = allowed_exceptions or (ControlledException,) # type: ignore def retry(operation): @wraps(operation) def wrapped(*args, **kwargs): last_raise = None for _ in range(retries_limit): try: return operation(*args, **kwargs) except allowed_exceptions as e: logger.waring( "%s 재시도, 원인: %s", operation.__qualname__, e ) last_raise = e raise last_raise return wrapped return retry
세 단계의 중첩된 함수가 필요했다. 첫 번째는 데코레이터의 파라미터를 받는 함수이다. 함수 내부의 다른 함수는 이렇게 전달된 파라미터를 로직에서 사용하는 클로저이다.
@with_retry() def run_operation(task): return task.run() @with_retry(retries_limit=5) def run_with_custom_retries_limit(task): return task.run() @with_retry(allowed_exceptions=(AttributeError,)) def run_with_custom_exception(task): return task.run() @with_retry(retries_limit=4, allowed_exceptions=(ZeroDivisionError, AttributeError,)) def run_with_custom_parameters(task): return task.run()
새로운 함수가 추가될때마다 들여쓰기가 추가되어 너무 많은 중첩함수가 필요할 수 있다. 또한 함수는 상태를 저장하지 않기때문에 객체가 하는 것처럼 내부 데이터를 관리하기가 어렵다.
장점
  1. 간단하고 직관적
  1. 유연한 매개변수의 사용
단점
  1. 모든 재시도 로직이 데코레이터 내부에 구현되어 있어야 하며, 로직을 수정하려면 데코레이터 자체를 수정해야 한다
 

데코레이터 객체

_DEFAULT_RETRIES_LIMIT = 3 class WithRetry: def __init__( self, retries_limit: int = _DEFAULT_RETRIES_LIMIT, allowed_exceptions: Optional[Sequence[Exception]] = None, ) -> None: self.retries_limit = retries_limit self.allowed_exceptions = allowed_exceptions or (ControlledException,) def __call__(self, operation): @wraps(operation) def wrapped(*args, **kwargs): last_raised = None for _ in range(self.retries_limit): try: return operation(*args, **kwargs): except self.allowed_exceptions as e: logger.warning( "%s 재시도, 원인: s%", operation.__qualname__, e ) last_raised = e raise last_raised return wrapped
@WithRetry(retries_limit=5) def run_with_custom_retries_limit(task): return task.run()
  1. 전달된 파라미터를 사용해 데코레이터 객체를 생성한다.
  1. 데코레이터 객체는 초기화를 진행한다.
  1. @ 연산이 호출된다.
  1. run_with_custom_retries_limit 함수를 래핑하여 __call__ 매직 메서드를 호출한다.
 
장점
  1. 재사용성과 확장성
 

기본값을 가진 데코레이터

@retry() # 기본값을 사용하기 위해서는 다음과 같이 호출한다. None을 파라미터로 전달한다. def function(): ...
@retry # 동작하지 않는다. 기본값이 없기 때문에. 함수가 전달된다. def function(): ...
보통의 경우 첫 번째 방식이 올바른 방식이다. 물론 두가지를 모두 지원하게 할 수도 있다.
 
@decorator(x=3, y=4) def function(x, y): return x + y function() # 7
def decorator(fuction=None, *, x=DEFAULT_X, y=DEFAULT_Y): if function is None: # decorator(...) 형태로 호출한 경우 def decorated(function): @wraps(function) def wrapped(): return function(x, y) return wrapped return decorated else: # decorator 형태로 호출한 경우 @wraps(function) def wrapped(): return function(x, y) return wrapped
여기서 파라미터는 키워드 전용이다. 이로써 서명이 간단해졌다.(위치 기반으로 파라미터를 넘기면 첫 번째 인자가 무엇인지 헷갈릴 것이다.)
 
def decorator(function=None, *, x=DEFAULT_X, y=DEFAULT_Y): if function is None: return lambda f: decorator(f, x=x, y=y) @wraps(function) def wrapped(): return function(x, y) return wrapped
함수가 제공되지 않으면 함수를 인자로 받아 나머지 파라미터와 함께 재귀적으로 decorator를 다시 호출하는 lambda 함수를 반환한다.
어떻게 하든 데코레이터의 파라미터는 키워드 전용으로 하는 것이 좋다. 각 값이 하는 일에 대한 컨텍스트 정보가 많지 않고, 위치 파라미터를 사용하면 변수의 의미를 명확히 알 수 없으므로.
 

코루틴을 위한 데코레이터

X, Y = 1, 2 def decorator(callable): """고정된 X와 Y 값으로 <callable> 호출""" @wraps(callable) def wrapped(): return callable(X, Y) return wrapped @decorator def func(x, y): return x + y @decorator async def coro(x, y): return x + y
문제가 있다.
 
import inspect def timing(callable): @wraps(callable) def wrapped(*args, **kwargs): start = time.time() result = callable(*args, **kwargs) latency = time.time() - start return {"latency": latency, "result": result} @wraps(callable) async def wrapped_coro(*args, **kwargs): start = time.time() result = await callable(*args, **kwargs) latency = time.time() - start return {"latency": latency, "result": result} if inspect.iscoroutinefunction(callable): return wrapped_coro return wrapped
두번째 래퍼는 코루틴을 위해 필요하다. 만약 이 부분이 없다면 문제가 발생한다.
  1. await 없이 코루틴을 호출하면 실제로는 작업이 끝나길 기다린 것이 아니므로 정확한 결과가 아니다.
  1. 반환하는 사전에서 result 키에 대한 값은 실제 결과 값이 아니라 코루틴이다. await 없이 사용하면 에러를 반환한다.
 

데코레이터를 위한 확장 구문

def _log(f, *args, **kwargs): print(f"함수이름: {f.__qualname__!r}, 파라미터: {args=} 와 {kwargs=}") return f(*args, **kwargs) @(lambda f: lambda *args, **kwargs: _log(f, *args, **kwargs)) def func(x): return x + 1
>>> func(3) 함수이름: 'func', 파라미터: args=(3,) 와 kwargs={}
가독성을 해치지 않는 범위에서 최대한 간결한 문장을 선택하자. 데코레이터의 표현을 읽기 어렵다면 두 개 이상의 함수로 나눠서 알기 쉽게 분리하도록 하자.
 

데코레이터 활용 우수 사례

  • 파라미터 변환
  • 코드 추적
  • 파라미터 유효성 검사
  • 재시도 로직 구현
  • 일부 반복 작업을 데코레이터로 이동하여 클래스 단순화
 

함수 서명 변경

def resolver_function(root, args, context, info): ...
위 함수를 여러 곳에서 사용하고 있어, 모든 파라미터의 처리 부분을 캡슐화하고 우리 애플리케이션에 알맞은 동작으로 변환하는 추상화를 하자.
 
def resolver_function(root, args, context, info): helper = DomainObject(root, args, context, info) ... helper.process()
데코레이터에서 함수의 서명을 변경하여 해당 도메인 객체가 직접 전달되는 것처럼 할 수 있다. 이 경우는 데코레이터는 원래의 파라미터를 가로채서 도메인 객체를 만들고, 데코레이팅된 함수에 helper 객체를 전달하고 있다.
 
@DomainArgs def resolver_function(helper): helper.process()
데코레이터를 사용해 이미 초기화된 helper 객체를 가진 것 처럼 서명을 변경할 수 있다.
 

파라마티 유효성 검사

데코레이터를 사용하여 파라미터의 유효성을 검사할 수 있다. DbC의 원칙에 따라 사전조건, 사후조건을 강제할 수도 있다. 유사한 객체를 반복적으로 생성하거나 추상화를 위해 유사한 변형을 반복하는 경우가 있다.
 

코드 추적

  • 함수의 실행 경로 추적(실행 함수 로깅)
  • 함수 지표 모니터링(CPU 사용량, 메모리 사용량)
  • 함수 실행 시간 측정
  • 언제 함수가 실행되고 전달된 파라미터는 무엇인지 로깅
 
 

데코레이터의 활용 - 흔한 실수

 

래핑된 원본 객체의 데이터 보존

가장 많은 실수는 원본 함수의 일부 프로퍼티나 또는 속성을 유지하지 않는 것이다.
def trace_decorator(function): def wrapped(*args, **kwargs): logger.info("%s 실행", function.__qualname__) return function(*args, **kwargs) return wrapped
@trave_decorator # 데코레이터 def process_account(account_id: str): # 원본 함수 """id 별 계정 처리""" logger.info("%s 계정 처리", account_id) ...
데코레이터를 정의하고 원본 함수 위에 정의했다. 코드만 확인해서는 문제가 없어보인다.
 
>>> help(process_acoount) Help on function wrapped in module decorator_warps_1: warpped(*args, **kwargs)
>>> process_account.__qualname__ 'trace_decorator.<locals>.wrapped' >>> process_account.__annotations__ {}
원본 함수의 정보를 출력하지 못하고 있다. 데코레이터가 원본 함수를 wrapped 함수로 변경했기 때문에 원본 함수의 속성이 아닌 wrapped 함수의 속성을 출력한다.
 
이는 크게 두 가지의 문제를 야기한다.
  1. 원본 함수를 알 수 없으므로 디버깅에 어려움을 겪을 수 있다.
  1. Docstring 문서까지 덮어씌워졌으므로 단위 테스트 코드에서 원본 함수의 정보를 볼 수 없게 된다.
 
def trace_decorator(fucntion): @wraps(fucntion) def wrapped(*args, **kwargs): logger.info("%s 실행", function.__qualname__) return function(*args, **kwargs) return wrapped
>>> from decorator_wraps_2 import process_account >>> help(process_account) Help on function process_account in module decorator_warps_2: process_account(account_id) processs an account by Id. >>> process_account.__qualname__ 'process_account'
@wraps 데코레이터를 적용해 래핑된 함수임을 알려주는 것으로 문제를 해결할 수 있다.
 
def decorator(origin_function): @warps(origin_function) def decorator_function(*args, **kwargs): # 데코레이터에 의한 수정 작업 return origin_function(*args, **kwargs) return decorator_function
기본적인 데코레이터 템플릿이다.
 

데코레이터 부작용 처리

 
잘못된 처리
def traced_fucntion_wrong(fucntion): logger.info("%s 함수 실행", function) start_time = time.time() @wraps(function) def wrapped(*args, **kwargs): result = function(*args, **kwargs) logger.info( "함수 %s의 실행 시간: %.2fs" function, time.time() - start_time ) return wrapped
@traced_function_wrong def process_with_delay(callback, delay=0) time.sleep(delay) return callback()
>>> from decorator_side_effects_1 import process_with_delay INFO: <funtion process_with_delay at 0x...> 함수 실행 # 함수를 임포트만 했을 뿐인데 실행이 된다. # 실제 함수를 호출하지 않았으므로 로그가 남지 않아야 한다.
>>> main() ... INFO: 함수 <funtion process_with_delay at 0x...>의 실행시간 8.67s >>> main() ... INFO: 함수 <funtion process_with_delay at 0x...>의 실행시간 13.67s >>> main() ... INFO: 함수 <funtion process_with_delay at 0x...>의 실행시간 17.67s
코드의 출력된 결과를 보면 문제를 파악할 수 있다.
  1. 함수를 임포트 했을 뿐인데 실행이 되고 있다.
  1. 실제 함수를 호출하지 않았지만 로그가 남고 있다.
  1. 실행 시간이 계속 증가하고 있다.
 
왜 그럴까?
@traced_function_wrong def process_with_delay(): ... process_with_delay = traced_function_wrong(process_with_delay)
위 코드는 모듈을 임포트할 때 실행된다. 함수에 설정된 start_time은 모듈을 최초 시작할 때 시간이 된다. 함수를 여러번 실행하더라도 최초의 시간으로부터 계산되기 때문에 계속 증가하는 것 처럼 보인다.
 
def traced_function(function): @wraps(function) def wrapped(*args, **kwargs): logger.info("%s 함수 실행", function.__qualname__) start_time = time.time() result = function(*args, **kwargs) logger.info( "함수 %s의 실행 시간: %.2fs", function.__qualname__, time.time() - start_time ) return wrapped
로그와 시작 시간을 @warps 내부로 옮겨 실행을 지연시킨다.
 
부작용의 의도적 활용
EVENT_REGISTRY = {} def register_evnet(event_cls): """모듈에서 접근 가능하도록 이벤트 클래스를 레지스트리에 등록""" EVENT_REGISTRY[event_cls.__name__] = event_cls return event_cls class Event: """기본 이벤트 객체""" class UserEvent: TYPE = "user" @register_event class UserLoginEvent(UserEvent): """사용자가 시스템에 접근했을떄 발생하는 이벤트""" @register_evnet class UserLogoutEvnet(UserEvent): """사용자가 시스템에서 나갈 때 발생하는 이벤트"""
>>> from decorator_side_effect_2 import EVENTS_REGISTRY >>> EVENT_REGISTRY {"UserLoginEvent: decorator_side_effect_2.UserLoginEvent", "UserLogoutEvent: decorator_side_effect_2.UserLogoutEvent"}
모듈의 공용 레지스트리(EVENT_REGISTRY)에 객체를 등록하여 의도적으로 부작용을 활용했다. 많은 웹 프레임워크나 널리 알려진 라이브러리들은 이 원리로 객체를 노출하거나 활용하고 있다.
 
 

어느 곳에서나 동작하는 데코레이터 만들기

데코레이터를 만들때는 일반적으로 재사용을 고려해서 동작하길 원한다. *args, **kwargs 서명을 사용하여 데코레이터를 정의하면 모든 경우에 사용할 수 있다.
 
원래 함수의 서명과 비슷하게 데코레이터를 정의하는 것이 좋을때가 있다.
  • 원래의 함수와 모양이 비슷하기 때문에 읽기가 쉽다.
  • 파라미터를 받아서 뭔가를 하려면 *args, **kwargs 를 사용하는 것이 불편하다.
 
from functools import wraps from log import logger class DBDriver: def __init__(self, dbstring: str) -> None: self.dbstring = dbstring def execute(self, query: str) -> str: return f"query {query} at {self.dbstring}" def inject_db_driver(function): """파라미터로 전달받은 데이터베이스 dns 문자열을 사용하여 DBDriver 인스턴스 생성""" @wraps def wrapped(dbstring): return function(DBDriver(dbstring)) return wrapped @inject_db_driver def run_query(driver): return driver.execute("test_function")
>>> run_query("test_OK") 'query test_function at test_OK'
 
잘 동작한다. 하지만 클래스 메서드에서 재사용하면 어떨까?
class DataHandeler: @inject_db_driver def run_qurey(self, driver): return driver.execute(self.__class__.__name__)
>>> DataHandeler().run_query("test_fails") Traceback(most recent call last): ... TypeError: wrapped() takes 1 positional argument but 2 were given
하나의 파라미터만 받도록 설계된 데코레이터는 연결 문자열 자리에 self 를 전달하고 두번째 파라미터에는 아무것도 전달하지 않아서 에러를 반환한다.
 
from functool import wraps from types import MethodType class inject_db_driver: """문자열을 DBDriver 인스턴스로 변환하여 래핑된 함수에 전달""" def __init__(self, function) -> None: self.function = function wraps(self.function)(self) def __call__(self, dbstring): return self.function(DBDriver(dbstring)) def __get__(self, instance): if instance is None: return self return self.__class__(MethodType(self.function, instance))
호출할 수 있는 객체를 메서드에 다시 바인딩하는 방법으로 해결할 수 있다.
함수를 개체에 바인딩하고 데코레이터를 새로운 호출 객체로 다시 생성한다. 함수의 경우 __get__ 메서드를 사용하지 않기 때문에 여전히 잘 작동한다.
 

데코레이터와 클린 코드

상속보다 컴포지션

GoF의 디자인 패턴 책의 내용에 기반한다.
 
class BaseResolverMixin: def __getattr__(self, attr: str): if attr.startswith("resolve_"): *_, actual_attr = attr.paririon("resolve_") else: actual_attr = attr try: return self.__dict__[actual_attr] except KeyError as e: raise AttributeError from e @dataclass class Customer(BaseResolverMixin): customer_id: str name: str address: str
__getattr__ 로 구현한 mixin 부모 클래스를 만들어 변수명에 “resolver_” 를 붙이는 방법
 
상속을 사용한 방법은 두가지 이유로 좋지 않다.
  • 상속은 코드를 재사용하는 가장 좋은 방법이 아니다. 좋은 코드는 계층 구조를 만드는 것이 아닌, 작고 응집력 있는 추상화를 통해 재사용된다.
  • 하위 클래스를 생성하는 것은 “is a”관계를 가졌을때 전문화를 해야하는 경우에 필요하다. 개념적으로 Customer 클래스가 BaseResolverMixin 클래스의 하위 관계에 있는지 생각해보자.
class connection: pass class EncryptedConnection(Connection): pass
이런 경우는 상속을 사용하는 것이 맞다. 결국 암호화된 연결은 특정한 종류의 연결이기 때문이다.
하지만 Mixin의 경우에는 여러 다른 클래스와 함께 혼합될 것이다. 왜냐하면 mixin 자체의 성격은 순전히 실용적이며 구현상의 목적을 가지고 있기 때문이다.
 
from dataclasses import dataclass def _resolver_method(self, attr): """__getattr__ 매직 매서드를 대신할 속성 결정(resolution) 메서드""" if attr.startswith("resolve_"): *_, actual_attr = attr.partition("resolve_") else: actual_attr = attr try: return self.__dict__[actual_attr] except KeyError as e: raise AttributeError from e def with_resolver(cls): """사용자 정의 결정 메서드를 __getattr__에 할당""" cls.__getattr__ = _resolver_method return cls @dataclass @with_resolver class Customer: customer_id: str name: str address: str
>>> customer = Customer("1", "name", "address") >>> customer.resolver_customer_id "1" >>> customer.resolver_name "name"
데코레이터를 사용한 방식
 
데코레이터를 사용한다는 것은 상속 대신에 컴포지션(다른 클래스를 가져와서 수정한 새로운 객체를 반환)을 사용하고 있다는 것이다.
 
 

데코레이터와 DRY 원칙

데코레이터는 재사용의 장점과 DRY(Don’t Repeat Yourself)원칙을 잘 따른다는 장점이 있다.
하지만 잘해야 한다(?) 왜냐면 어떤 사용자는 함수의 로직을 완적히 이해하기 위해 데코레이터의 경로를 따라 가보길 원할 수 있다. 따라서 이 복잡성이 가치가 있어야 한다는 점을 기억해야 한다.
그닥 재사용할 필요가 없을 경우 별개의 함수나 작은 클래스로도 충분한 경우가 있다.
 
재사용이 많다는 것은 어떤 기준으로 봐야 할까? 데코레이터는 언제 만들어야 할까?
  • 처음부터 데코레이터를 만들지 않는다. 패턴이 생기고 데코레이터에 대한 추상화가 명확해지면 그 때 리팩터링 한다.
  • 데코레이터가 적어도 3회 이상 필요한 경우에만 구현한다.
  • 데코레이터 코드를 최소한으로 유지한다.
 

데코레이터와 관심사 분리

코드의 재사용은 응집력이 있는 컴포넌트를 만드는 것이다. 즉, 최소한의 책임을 가져서 오직 한 가지 일만 해야하며, 그 일을 잘 해야 한다. 컴포넌트가 작을수록 재사용성이 높아진다. 그리고 여러 상황에 쓰일 수 있게 된다.
 
def traced_function(function): @wraps(function) def wrapped(*args, **kwargs): logger.info("%s 함수 실행", function.__qualname__) start_time = time.time() result = function(*args, **kwargs) logger.info( "함수 %s의 실행시간: %.2fs", function.__qualname__, time.time() - start_time ) return result return wrapped
이 데코레이터는 문제가 있다. 하나 이상의 작업을 실행하고 있다.
특정 함수의 호출과 실행시간 2가지를 기록한다. 좀 더 구체적이고 제한 적인 책임을 지닌 데코레이터로 구분되어야 한다.
 
def log_execution(function): @wraps(function) def wrapped(*args, **kwargs): logger.info("%s 함수 실행", function.__qualname__) return function(*args, **kwargs) return wrapped def measure_time(function): @wraps(function) def wrapped(*args, **kwargs): start_time = time.time() result = function(*args, **kwargs) logger.info( "함수 %s의 실행시간: %.2fs", function.__qualname__, time.time() - start_time ) return result return wrapped
@measure_time @log_execution def operation(): ...
데코레이터에 하나 이상의 책임을 두면 안된다. 데코레이터가 적용되는 순서도 중요하다.
 

좋은 데코레이터 분석

좋은 데코레이터는 다음과 같은 특성을 보인다.
 
캡슐화와 관심사의 분리
실제로 하는 일과 데코레이팅하는 일의 책임을 명확히 해야 한다. 즉, 데코레이터의 클라이언트는 내부에서 어떻게 구현했는지 전혀 알 수 없는 블랙박스 모드로 동작해야 한다.
 
독립성
데코레이터가 하는 일은 최대한 독립적이어야 하며, 데코레이팅되는 객체와 최대한 불리 되어야한다.
 
재사용성
데코레이터는 여러 유형에 적용 가능한 형태가 바람직하다. 하나의 함수에만 적용된다면 데코레이터가 아니라 함수로 대신할 수도 있기 때문이다. 범용적이여야 한다.
 
@app.task def my_task(): ...
celery 프로젝트에서의 데코레이터는 좋은 예이다. 완벽한 캡슐화와 관심사의 분리이다.
  • app.task 는 많은 로직과 코드를 래핑하지만 원본 함수와는 관련이 없다.
  • 데코레이터가 하는 일을 살펴볼 필요가 없다. 다른 말로는 어떤 세부 사항도 누설하지 않는 정확한 추상화이다.
 
@route("/", method=["GET"]) def view_handeler(request): ...
웹 프레임워크의 뷰 핸들러는 데코레이터를 통해 URL로 등록된다.
  • @route 데코레이터가 하는 일을 거의 알지 못한다. 완벽한 캡슐화를 제공한다.

댓글

guest